查看原文
其他

520,花了一夜给女朋友写了个走迷宫游戏

bigsai bigsai 2023-02-06

起因

先看效果图(文末有动态图)(在线电脑尝试地址http://biggsai.com/maze.html):

项目github地址:https://github.com/javasmall/mazegame


作为程序猿,常常因为身务繁忙,经常忙于coding很少闲暇来顾及女朋友,也常被吐槽为渣男。


但是,渣着渣着,却发现520到了。卧槽,520?!


我们常常后悔自己冲动时候说过的话,我准备个毛线,啥都没准备,但好面子的我也不可能随随便便欺骗人家女孩子(虽然已经骗了😭),

习惯性打开淘宝,逛着看看有没有啥合适的,一想今天已经19号了,卧槽要凉了难道?


一想还有美团补救一下可以送的到,打开美团发现都是吃喝玩,完蛋,真的要凉了。

深夜,我在忏悔自己说过的大话,没本事不能瞎说啊,一晚上啥也干不了哇。

等等,还有一晚上耶!

此时我突然眼前一亮,对于程序猿,一晚上也能制造属于他的浪漫!

翻开各种搜索引擎、博客搜素一番:程序员520表白神器 代码 果然不出我所料!有东西,哈哈哈ctrl+c,ctrl+v我可太会了,浪漫要来了!

找了几个代码,发现哇塞好牛哇,还能跑起来,但是发现好多雷同我也找不到原作者是谁,但我心中非常崇拜作者的才华(救命之恩)。


这时我的头脑又浮现她时对我说的一句话:"一点都不上心"这些可能确实都已经很老套和小儿科了,不行,我要以我自己的方式来!我要有点我自己的特色。

我琢磨着怎么用熟悉的数据结构与算法搞个有趣的东西。

追忆着我知道的数据结构与算法:链表、队列、栈、图、dfs、bfs、并查集、Dijkstra、Prime……

以前隐隐约约好像记得有人用Java Swing配合并查集算法画迷宫,我是否可以写一个走迷宫的小游戏呢?但我搜了一下并未发现有HTML、JavaScript版本的,但Swing那玩意除了特定场景基本没人用我要整一个不一样的,虽然JavaScript和HTML我不太熟,但我应该可以,加油💪!


分析

我需要从0到1的设计实现一个走迷宫游戏,但这里面一定是困难阻阻,不过但凡问题都可以先拆开分步,然后逐个击破合并。

对于一个走迷宫的小游戏,我想了一下可能需要掌握以下的知识:

  • 搞懂一个初始位置至结束位置有到达路径的迷宫生成算法

  • 用JavaScript在canvas上画棋盘(迷宫初始状态)

  • 利用迷宫生成算法生成一个迷宫(擦除需要擦掉的线)

  • 利用JavaScript、事件监听和canvas画图实现方块移动(要考虑不出界、不穿墙)

琢磨的过程大概是

  • 画单条线—>画多个线—>擦线(形成迷宫)—>方块移动、移动约束(不出界不穿墙)—>完成游戏

画线(棋盘)

对于html+js(canvas)画的东西以前没接触过,但之前学过Java Swing写过五子棋游戏、翻卡牌游戏和这个有点类似,在html中有个canvas 的画布,可以在上面画一些东西和声明一些监听(键盘监听)。

对于这种canvas、Java Swing、QT等画图库,如果你使用它进行画图,要清楚你画上去的东西其实就是一条条线,没有实际的属性,你只不过需要在编程语言中根据数据结构与算法去操作画布,让canvas画布的内容是对你写的数据结构或算法是一个正确合理的展现

所以对于迷宫来说,一个个线条线条是没有属性的,只有位置x,y,你操作这个画布时候,可能和我们习惯的面相对象思维不一样,设计的线或者点的时候,要能够通过计算推理这些点、线在什么位置,在后续画线、擦线、移动方格的时候根据这个位置进行操作让整个迷宫在视觉效果上是完整统一的。

对于这个画棋盘的步骤也很简单,首先尝试画一条线,事先想好迷宫每个方格格的大小和方格总个数,之后按照起始(左上)坐标顺序画水平方向、竖直方向的线(平行线之间起末点位置上有规律的),最终就可以实现一个视觉上的迷宫。

<!DOCTYPE html>
<html>
<head>
    <title>MyHtml.html</title>
</head>
<body>
    <canvas id="mycanvas" width="600px" height="600px"></canvas>
</body>
<script type="text/javascript">

    var chessboradsize=14;//棋盘大小
    var chess = document.getElementById("mycanvas");
    var context = chess.getContext('2d');

    function drawChessBoard(){//绘画
        for(var i=0;i<chessboradsize+1;i++){
            context.strokeStyle='gray';//可选区域
            context.moveTo(15+i*30,15);//垂直方向画15根线,相距30px;
            context.lineTo(15+i*30,15+30*chessboradsize);
            context.stroke();
            context.moveTo(15,15+i*30);//水平方向画15根线,相距30px;棋盘为14*14;
            context.lineTo(15+30*chessboradsize,15+i*30);
            context.stroke();
        }
    }
    drawChessBoard();//绘制棋盘

</script>
</html>

实现效果


画迷宫

随机迷宫怎么生成?怎么搞?又陷入一脸懵逼。

因为我们想要迷宫,那么就需要这个迷宫出口和入口有连通路径,不研究的话很难知道用什么算法生成这个迷宫。这时耳角传来熟悉的声音:并查集(不相交集合)

迷宫和不相交集合有什么联系呢?(规则)

之前笔者在前面数据结构与算法系列中曾经介绍过并查集(不相交集合),它的主要功能是森林的合并、查找:不联通的通过并查集能够快速将两个森林合并,并且能够快速查询两个节点是否在同一个森林(集合)中!

而随机迷宫生成正也利用了这个思想:在每个方格都不联通的情况下,是一个棋盘方格,每个迷宫格子相对独立,这是它的初始状态;后面生成可能需要若干相邻节点进行联通(合并为一个集合),且这个节点可以跟邻居可能相连,也可能不相连。我们可以通过并查集实现其底层数据结构的支撑。

在这里面,我们把每个格子当成一个个集合元素,而每个集合与周围的墙则是证明其是否直接联通,我们就是要通过联通一部分方格(擦掉部分墙)实现整个随机迷宫。

具体思路为:(主要理解并查集)

1:定义好不想交集合的基本类和方法(search,union等)
2:数组初始化,每一个数组元素都是一个集合,值为-1
3:随机查找一个格子(一维数据要转换成二维,有点麻烦),在随机找一面墙(也就是找这个格子的上下左右),还要判断找的格子出没出界是否为一个合法的格子。

  • 具体生成一个随机数m(小于迷宫总格子数)

  • 将一维随机数m转成在迷宫横纵二维位置位置p,具体为:[m/长,m%长] 这里的表示迷宫的行数或者列数。

  • 在位置p的上下左右随机找一个位置q[m/长+1,m%长][m/长-1,m%长][m/长,m%长+1][m/长,m%长-1]

  • 判断是否越界,如果越界重新查找,否则进行下一步。

4:判断两个格子p和q(这时候要将二维坐标转成其一维数组编号)是否在一个集合(并查集查找)。如果在,则返回第三步重新找,如果不在,那么把墙挖去。
5把墙挖去(合并)有点繁琐,就算两个方格不连通,需要再通过位置判断它那种墙(上下隔离还是左右隔离),然后再通过计算精确定位到这个墙起点末点位置然后擦掉(需要考虑相当多的细节)。
6:重复上面工作,直到第一个(1,1)和(n,n)联通停止得到一个完整的迷宫。虽然采用随机数找方格找墙,但是这个数据量效率和结果都还是挺不错的。

在其中要搞清一维二维数组的关系。一维是真实数据,进行并查集查找、合并操作。转化为二维更多是为了查找位置。要搞懂转化!

注意:避免混淆,搞清数组的地址和逻辑矩阵位置。数组从0开始的,逻辑上你自己判断,别搞混淆!


你可能会问,这个算法为什么最终能生成一个起始末尾联通迷宫,因为我们的终止就是以它为条件,不连通的话就会让迷宫内随机联通一个本不联通的迷宫,而这种可能性是很有限的,所以到不了最坏情况就能满足迷宫联通,然后又是随机找点,可以让迷宫看起来更匀称一些。

主要逻辑为:

while(search(0)!=search(aa*aa-1))//主要思路
{
  var num = parseInt(Math.random() * aa*aa );//产生一个小于196的随机数
  var neihbour=getnei(num);
  if(search(num)==search(neihbour)){continue;}
  else//不在一个上
  {
    isling[num][neihbour]=1;isling[neihbour][num]=1;
    drawline(num,neihbour);//划线
    union(num,neihbour);

  }
}

那么在前面的代码为

<!DOCTYPE html>
<html>
<head>
    <title>MyHtml.html</title>
</head>
<body>
<canvas id="mycanvas" width="600px" height="600px"></canvas>

</body>
<script type="text/javascript">
    var chessboradSize=14;
    var chess = document.getElementById("mycanvas");
    var context = chess.getContext('2d');

    var tree = [];//存放是否联通
    var isling=[];//判断是否相连
    for(var i=0;i<chessboradSize;i++){
        tree[i]=[];
        for(var j=0;j<chessboradSize;j++){
            tree[i][j]=-1;//初始值为0
        }
    }  for(var i=0;i<chessboradSize*chessboradSize;i++){
        isling[i]=[];
        for(var j=0;j<chessboradSize*chessboradSize;j++){
            isling[i][j]=-1;//初始值为0
        }
    }

    function drawChessBoard(){//绘画
        for(var i=0;i<chessboradSize+1;i++){
            context.strokeStyle='gray';//可选区域
            context.moveTo(15+i*30,15);//垂直方向画15根线,相距30px;
            context.lineTo(15+i*30,15+30*chessboradSize);
            context.stroke();
            context.moveTo(15,15+i*30);//水平方向画15根线,相距30px;棋盘为14*14;
            context.lineTo(15+30*chessboradSize,15+i*30);
            context.stroke();
        }
    }
    drawChessBoard();//绘制棋盘

    function getnei(a)//获得邻居号  random
    {
        var x=parseInt(a/chessboradSize);//要精确成整数
        var y=a%chessboradSize;
        var mynei=new Array();//储存邻居
        if(x-1>=0){mynei.push((x-1)*chessboradSize+y);}//上节点
        if(x+1<14){mynei.push((x+1)*chessboradSize+y);}//下节点
        if(y+1<14){mynei.push(x*chessboradSize+y+1);}//有节点
        if(y-1>=0){mynei.push(x*chessboradSize+y-1);}//下节点
        var ran=parseInt(Math.random() * mynei.length );
        return mynei[ran];

    }
    function search(a)//找到根节点
    {
        if(tree[parseInt(a/chessboradSize)][a%chessboradSize]>0)//说明是子节点
        {
            return search(tree[parseInt(a/chessboradSize)][a%chessboradSize]);//不能压缩路径路径压缩
        }
        else
            return a;
    }
    function value(a)//找到树的大小
    {
        if(tree[parseInt(a/chessboradSize)][a%chessboradSize]>0)//说明是子节点
        {
            return tree[parseInt(a/chessboradSize)][a%chessboradSize]=value(tree[parseInt(a/chessboradSize)][a%chessboradSize]);//不能路径压缩
        }
        else
            return -tree[parseInt(a/chessboradSize)][a%chessboradSize];
    }
    function union(a,b)//合并
    {
        var a1=search(a);//a根
        var b1=search(b);//b根
        if(a1==b1){}
        else
        {
            if(tree[parseInt(a1/chessboradSize)][a1%chessboradSize]<tree[parseInt(b1/chessboradSize)][b1%chessboradSize])//这个是负数(),为了简单减少计算,不在调用value函数
            {
                tree[parseInt(a1/chessboradSize)][a1%chessboradSize]+=tree[parseInt(b1/chessboradSize)][b1%chessboradSize];//个数相加  注意是负数相加
                tree[parseInt(b1/chessboradSize)][b1%chessboradSize]=a1;       //b树成为a树的子树,b的根b1直接指向a;
            }
            else
            {
                tree[parseInt(b1/chessboradSize)][b1%chessboradSize]+=tree[parseInt(a1/chessboradSize)][a1%chessboradSize];
                tree[parseInt(a1/chessboradSize)][a1%chessboradSize]=b1;//a所在树成为b所在树的子树
            }
        }
    }

    function drawline(a,b)//划线,要判断是上下还是左右
    {

        var x1=parseInt(a/chessboradSize);
        var y1=a%chessboradSize;
        var x2=parseInt(b/chessboradSize);
        var y2=b%chessboradSize;
        var x3=(x1+x2)/2;
        var y3=(y1+y2)/2;
        if(x1-x2==1||x1-x2==-1)//左右方向的点  需要上下划线
        {
            context.strokeStyle = 'white';
            context.clearRect(29+x3*30, y3*30+16,2,28);
        }
        else
        {
            context.strokeStyle = 'white';
            context.clearRect(x3*30+16, 29+y3*30,28,2);
        }
    }
    while(search(0)!=search(chessboradSize*chessboradSize-1))//主要思路
    {
        var num = parseInt(Math.random() * chessboradSize*chessboradSize );//产生一个小于196的随机数
        var neihbour=getnei(num);
        if(search(num)==search(neihbour)){continue;}
        else//不在一个上
        {
            isling[num][neihbour]=1;isling[neihbour][num]=1;
            drawline(num,neihbour);//划线
            union(num,neihbour);

        }
    }
</script>
</html>

这样,离胜利又进了一步,实现效果:


方块移动

这部分我采用的方法不是动态真的移动(关键我不会哈哈),而是一格一格的跳跃,也就是当走到下一个格子将当前格子的方块擦掉,在移动的那个格子中再画一个方块,选择方块是因为方块更方便擦除绘画计算,可以根据像素大小精准擦除。当然熟悉JavaScript的可以弄个小人进去玩玩。

另外,在移动中要注意不能隔空穿墙、越界。那么怎么判断呢?很好办,移动前目标方格,我们判断其是否直接联通,注意是直接联通而不是联通(很可能绕一圈联通但不能直接越过去,所以这里并查集不能压缩路径哦),如果直接不连通,那么不进行操作,否则进行方块移动。

另外,事件的监听上下左右自己百度查一查就可以得到,添加按钮对一些事件监听,完成整个游戏这些不是最主要的。

为了丰富游戏可玩性,将方法封装,可以设置关卡(只需改变迷宫大小),这样就可以实现通关了。


结语

在线尝试地址 http://biggsai.com/maze.html,代码可以到github上下载或到bigsai公众号回复:迷宫 即可获得!

避免吃狗粮,动态图图片换了一下(排行榜功能被阉割了):


最后,笔者水平有限,如果有纰漏和不足之处还请指出另外,如果感觉不错可以点个赞,关注个人公众号bigsai 更多经常与你分享,关注回复bigsai获取精心准备的学习资料一份!原文链接可以到github查看源码!

推荐阅读

 并查集,不就一并和一查?
 太透彻了:约瑟夫环的三种解法
 Java自学方法和路线,我万字推荐你这样学
 栈,就后进先出?
 面试官让手写各种队列,俺差点点没写出来


点个赞和在看你最好看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存